Java本身具有动态性,需要慎重选择代码生成策略。从前文的讨论中可以得出以下结论:
无论在自适应运行时环境中使用何种代码生成策略,代码执行效率可以用以下等式表示:
总体执行时间 = 代码生成时间 + 代码执行时间
换句话说,如果JVM把大量精力用在生成代码、优化代码上,尽管可以使生成代码的质量更高,但却会使总体执行时间增加。人们总是希望JVM能把所有精力用在执行自己编写的业务代码上,而不是用来做垃圾回收或代码生成。
但实际上,如果不花精力来为代码运行做准备的话,运行效率又很愁人,仍然会使总体执行时间变长。
为了能够对此作出取舍,JVM需要知道对哪些方法做优化编译是能够收回成本的。
当然,还有一些其他因素会对 总体执行时间产生影响,例如JVM执行垃圾回收,但这些内容超出了本章的范畴,在第3章中会对此详细阐述。这里需要提到的是,有时候代码优化可以通过产生高效的代码来降低JVM执行垃圾回收带来的效率损耗,例如应用 逃逸分析来减少创建对象的操作或者直接在栈上创建对象,后文会对此有介绍。
正如之前提到的,对自适应运行时来说,纯解释执行或完全JIT编译都不是真正实用的策略,前者执行效率太低,后者编译代码的时间又太长,都会延长总体执行时间。
为了能够决定是否应该对某个方法做优化编译,就需要判断出该方法是否够 热,一视同仁肯定是不成的。正如前面章节中提到的,有几种途径可以用来判断某个方法是否够热,通常都会对代码执行时间进行采样收集,由运行时来决定是否执行优化编译,采样数据收集的越多,运行时做出的决策越准确。如果只简单对几个方法进行采样的话,是无法对代码的执行情况做出准确判断的,但同时,收集采样数据本身也会产生性能损耗,这就涉及到一个平衡取舍的问题了。
调用计数器可以用来对方法进行采样,它会跟踪每个方法,每次调用方法时都会将计数器加1,这可以通过字节码解释器或在编译为本地代码时插入额外的add
指令实现。
而对于使用JIT编译器的运行时来说,尽管没有纯解释器执行的低效,但调用计数器仍可能因为CPU中缓存失效等问题而降低运行时的执行效率。这是因为每次调用方法时都会附带调用add
指令,从而频繁对内存中的某个位置执行写操作。
还有一种采样方式可以有效的利用缓存,这就是线程采样。这种方法会周期性的对当前正在运行的Java线程进行检查,记录其指令指针的内容,因此无需对原始代码做修改。
为了获取线程的上下文信息,就需要将线程挂起,但挂起线程的代价却非常大。因此,要想不打断任何线程执行的情况下完成大量的采样工作,JVM就需要自己实现线程才行,而这是定制的操作系统(例如Oracle JRockit Virtual Edition)或者专用硬件的才能支持。
某些硬件平台,例如Intel IA-64,提供了可供应用程序使用的硬件检测机制。即使针对IA-64平台生成的代码非常复杂,硬件架构仍然可以保证可以以较低的成本完成采样工作,因此可以更好的制定优化决策。
硬件采样的另一个优势是,除了指令指针外,可以获得其他多项数据,硬件检测器可以输出分支预测判断错误,或者CPU缓存失效的情况,而运行时可以使用这些信息对代码做更有针对性的优化。例如,通过修改导致分支预测失败的判断条件,以及预先获取导致CPU缓存失效的数据来解决这些问题。因此,高效的硬件采样为自适应运行环境能够生成高效的本地代码打下了坚实的基础。
在汇编代码中,方法调用是通过call
指令完成的,不同平台上call
指令的具体格式不尽相同。
在面向对象语言中,虚拟方法分派通常会被编译为 分派表(dispatch table)中的 间接调用(indirect call)(即需要从内存中读取真正的调用地址)。这是因为,根据不同的类继承结构,分派虚拟调用时可能会有多个接收者。每个类中都有一个分派表,其中包含了其虚拟调用的接收者信息。静态方法和确知只有一个接收者的虚拟方法会被转化为包含确定调用地址的 直接调用(译者注,例如在Java中,final方法肯定只有一个调用接收者,可以转化为直接调用,甚至可能会被内联到调用点)。很明显,这可以大大加快执行速度。
在本地代码中,静态调用是类似于这样的:
call 0x2345670 ;;跳转到指定位置
而虚拟调用是这样的
mov eax, [esi] ;;从寄存器esi中获取地址信息 call [eax+0x4c] ;;0x4c是分派表中偏移,[eax + 0x4c]是调用的具体位置
从上面的例子可以看出,执行虚拟调用需要访问2次内存,执行效率比静态调用慢。
假设应用程序是使用C++开发的,对代码生成器来说,在编译时已经可以获取到程序的所有结构性信息。例如,由于在程序运行过程中,代码不会发生变化,所以在编译时从代码中可判断出,某个虚拟方法是否只有一种实现。正因如此,编译器不仅不需要因为废弃代码而记录额外信息,还可以将那些只有一种实现的虚拟方法转化为静态调用。
假如应用程序是使用Java开发的,当JIT编译器需要编译某个方法时,最讨人喜欢的是那些永远只存在一种实现的,这样,编译器就可以像前面提到的C++编译器一样做很多优化,例如将虚拟调用转化为直接调用。但是,由于Java允许在程序运行期间修改代码,如果某个方法没有声明final
修饰符,那这个方法就有可能在运行期间被修改,因此,即使这个方法看起来几乎不可能有其他实现,编译器也不能将之优化为直接调用。
在Java世界中,有一些场景现在看起来和谐稳定,编译器有爱,优化措施得力,但是,当某天程序发生了改变的话,就需要将相关的优化全部撤销。对于Java来说,为了能够媲美C++程序的执行速度,就需要一些特殊的优化措施。
JVM使用的策略就是 “赌”。JVM代码生成策略的假设条件是正在运行的代码永远不变,事实上,大部分时间里确实如此。但如果正在运行的代码发生了变化,违反了假设条件,就会触发其簿记系统(bookkeeping system,用于记录相关信息,跟踪方法编译和调用等)的回调功能。此时,基于原先假设条件生成的代码就需要被废弃掉,重新生成,例如为已经转化为直接调用的虚拟调用重新生成相关代码。 因此,“赌输”的代价是很大的,但如果 “赌赢”的概率非常高,则从中获得的性能提升就会非常大,值得一试。
一般来说,JVM和JIT编译器所作的典型假设包括以下几点:
NaN
。对于一些比较少见的场景,可以使用硬件指令来替换对本地浮点数函数库的调用。try
语句块中几乎不会抛出异常。因此,可以将catch
语句块中的代码作为冷方法对待。fsin
都能够达到精度要求。如果真的达不到,就抛出异常,调用本地浮点数函数库完成计算。在使用预编译的静态环境中,程序是运行在封闭世界的,不需要做上述假设。而对于自适应运行时来说,当实际情况违反了假设条件后,就需要撤销之前所作的相关决策。理论上,只要JVM可以用较小的代价完成撤销工作,它可以做任何假设。因此,有了 “赌”这个机制,自适应运行时所能发挥的威力就比静态环境强大得多。
其实,“赌赢”并不容易。如果将小概率时间误判为频繁发生的时间,为了避免重新生成代码,只要不奢求程序执行效率可以像静态编译一样就好了。但如果将频繁发生的事件误判为小概率事件,就会由于重优化或去优化而大大延长代码生成时间。这里就涉及到如何进行取舍的问题,找准其中的平衡点是很具艺术性的工作,也是构建高性能运行时的关键所在。假如现在已经找到了这个平衡点,例如JRockit是通过收集所有相关事件的运行时反馈信息来制定决策的,那么自适应运行时就能比静态运境运行得更有效率。